En este artículo vamos a hacer un repaso exhaustivo de cómo acelerar sustancialmente tu código Python usando numba. Ya hablamos sobre la primera versión de numba en el blog, allá por 2012, pero ha habido importantes cambios desde entonces y la herramienta ha cambiado muchísimo. Recientemente Continuum publicó numba 0.17 con una nueva documentación mucho más fácil de seguir, pero aun así no siempre queda claro cuál es el camino para hacer que funcione, como quedó patente con el artículo sobre Cython de Kiko. Por ello, en este artículo voy a aclarar qué puede y qué no puede hacer numba, cómo sacarle partido y voy a detallar un par de ejemplos exitosos que he producido en los últimos meses.
Hablando de las nuevas versiones de numba, en su web podéis ver una evolución temporal del rendimiento de algunas tareas que utiliza asv para la visualización.
Como podemos leer en la documentación, numba tiene dos modos de funcionamiento básicos: el modo object y el modo nopython.
Por defecto numba usa el modo nopython siempre que puede, y si no pasa a modo object. Nosotros vamos a forzar el modo nopython (o «modo estricto» como me gusta llamarlo) porque es la única forma de aprovechar el potencial de numba.
El problema del modo nopython es que los mensajes de error son totalmente inservibles en la mayoría de los casos, así que antes de lanzarnos a compilar funciones con numba conviene hacer un repaso de qué no podemos hacer para anticipar la mejor forma de programar nuestro código. Podéis consultar en la documentación el subconjunto de Python soportado por numba en modo nopython, y ya os aviso que, al menos de momento, no tenemos list comprehensions, generadores ni algunas cosas más. Permitidme que resalte una frase sacada de la página principal de numba:
"With a few annotations, array-oriented and math-heavy Python code can be just-in-time compiled to native machine instructions, similar in performance to C, C++ and Fortran". [Énfasis mío]
Siento decepcionar a la audiencia pero numba no acelerará todo el código Python que le echemos: está enfocado a operaciones matemáticas con arrays. Aclarado este punto, vamos a ponernos manos a la obra con un ejemplo aplicado :)
Puedes instalar numba en Windows, OS X y Linux con conda usando este comando:
conda install numba
conda se ocupará de instalar una versión correcta de LLVM, así que no tendrás que compilarla tú mismo. Y ya está.
Ahora viene una opinión personal pero que considero importante: si eres usuario de paquetes científicos y aún no estás utilizando conda (o Anaconda) para gestionarlos, estás en la edad de piedra. Me declaro fanboy absoluto de Continuum Analytics por crear una herramienta de código abierto (conda está en GitHub) que soluciona por fin y de una vez por todas los problemas y frustración que hemos tenido como comunidad desde hace 15 años y que Guido y otros se negaron a atajar. Yo llevo en esto solo desde 2011 pero aún recuerdo lo que es intentar compilar SciPy en Windows. Hazte un favor e instala Miniconda.
Voy a tomar directamente el ejemplo que usó Kiko para su artículo sobre Cython y vamos a ver cómo podemos utilizar numba (y un poco de astucia) para acelerar esta función:
"Por ejemplo, imaginemos que tenemos que detectar valores mínimos locales dentro de una malla. Los valores mínimos deberán ser simplemente valores más bajos que los que haya en los 8 nodos de su entorno inmediato. En el siguiente gráfico, el nodo en verde será un nodo con un mínimo y en su entorno son todo valores superiores:
(2, 0) | (2, 1) | (2, 2) |
(1, 0) | (1. 1) | (1, 2) |
(0, 0) | (0, 1) | (0, 2) |
¡Vamos allá!
In [1]:
%install_ext http://raw.github.com/jrjohansson/version_information/master/version_information.py
In [2]:
%load_ext version_information
In [3]:
%version_information numpy, numba, cython
Out[3]:
Vamos a empezar por importar los paquetes necesarios e inicializar la semilla del generador de números aleatorios:
In [4]:
import numpy as np
import numba
np.random.seed(0)
Creamos nuestro array de datos:
In [5]:
data = np.random.randn(2000, 2000)
Y voy a copiar descaradamente la función de Kiko:
In [6]:
def busca_min(malla):
minimosx = []
minimosy = []
for i in range(1, malla.shape[1]-1):
for j in range(1, malla.shape[0]-1):
if (malla[j, i] < malla[j-1, i-1] and
malla[j, i] < malla[j-1, i] and
malla[j, i] < malla[j-1, i+1] and
malla[j, i] < malla[j, i-1] and
malla[j, i] < malla[j, i+1] and
malla[j, i] < malla[j+1, i-1] and
malla[j, i] < malla[j+1, i] and
malla[j, i] < malla[j+1, i+1]):
minimosx.append(i)
minimosy.append(j)
return np.array(minimosx), np.array(minimosy)
In [7]:
busca_min(data)
Out[7]:
In [8]:
mx, my = busca_min(data)
mx.size / data.size
Out[8]:
Tenemos que más de un 10 % de los elementos de la matriz cumplen la condición de ser «mínimos locales», así que no es nada despreciable. Esto en nuestro ejemplo hace un total de más de 400 000 elementos:
In [9]:
mx.size
Out[9]:
Ahora la idea de crear dos listas y añadir los elementos uno a uno me gusta todavía menos, así que voy a cambiar de enfoque. Lo que voy a hacer va a ser crear otro array, de la misma forma que nuestros datos, y almacenar un valor True
en aquellos elementos que cumplan la condición de mínimo local. De esta forma cumplo también una de las reglas de oro de Software Carpentry: "Always initialize from data".
In [10]:
def busca_min_np(malla):
minimos = np.zeros_like(malla, dtype=bool)
for i in range(1, malla.shape[1]-1):
for j in range(1, malla.shape[0]-1):
if (malla[j, i] < malla[j-1, i-1] and
malla[j, i] < malla[j-1, i] and
malla[j, i] < malla[j-1, i+1] and
malla[j, i] < malla[j, i-1] and
malla[j, i] < malla[j, i+1] and
malla[j, i] < malla[j+1, i-1] and
malla[j, i] < malla[j+1, i] and
malla[j, i] < malla[j+1, i+1]):
minimos[i, j] = True
return np.nonzero(minimos)
Encima puedo aprovechar la estupenda función nonzero
de NumPy. Compruebo que las salidas son iguales:
In [11]:
np.testing.assert_array_equal(busca_min(data)[0], busca_min_np(data)[0])
np.testing.assert_array_equal(busca_min(data)[1], busca_min_np(data)[1])
Y evalúo los rendimientos:
In [12]:
%timeit busca_min(data)
In [13]:
%timeit busca_min_np(data)
Parece que los tiempos son más o menos parecidos, pero al menos ya no tengo dos objetos en memoria que van a crecer de manera aleatoria. Vamos a ver ahora cómo nos puede ayudar numba a acelerar este código.
numba.jit(nopython=True)
Como hemos dicho antes, vamos a forzar que numba funcione en modo nopython para garantizar que obtenemos una mejora en el rendimiento. Si intentamos compilar la función definida en primer lugar va a fallar, porque ya hemos dicho más arriba que una de las condiciones es que no se puede asignar memoria a objetos nuevos:
In [14]:
busca_min_jit = numba.jit(nopython=True)(busca_min)
busca_min_jit(data)
En este caso la traza es inservible y especificar los tipos de entrada no va a ayudar. Solo para verificar, vamos a ver qué pasa con el rendimiento si no forzamos el modo estricto:
In [15]:
busca_min_jit_object = numba.jit()(busca_min)
In [16]:
%timeit busca_min_jit_object(data)
Pocas ganancias respecto a la función sin compilar. ¿Qué pasa si intentamos lo mismo con la segunda función?
In [17]:
busca_min_np_jit = numba.jit(nopython=True)(busca_min_np)
busca_min_np_jit(data)
Me dice que no conoce la función zeros_like
. Si acudimos a la documentación, podemos ver las características de NumPy soportadas por numba y las funciones de creación de arrays no figuran entre ellas. Esto es consistente con lo que hemos dicho más arriba: no vamos a poder asignar memoria a objetos nuevos.
¿Estamos en un callejón sin salida entonces? ¡En absoluto! Lo que vamos a hacer va a ser separar la parte intensiva de la función para aplicar numba.jit
sobre ella, e inicializar todos los valores desde fuera. Para los que hayan usado subrutinas en Fortran este enfoque les resultará familiar :)
In [21]:
def busca_min_np_jit(malla):
minimos = np.zeros_like(malla, dtype=bool)
_busca_min(malla, minimos)
return np.nonzero(minimos)
@numba.jit(nopython=True)
def _busca_min(malla, minimos):
for i in range(1, malla.shape[1]-1):
for j in range(1, malla.shape[0]-1):
if (malla[j, i] < malla[j-1, i-1] and
malla[j, i] < malla[j-1, i] and
malla[j, i] < malla[j-1, i+1] and
malla[j, i] < malla[j, i-1] and
malla[j, i] < malla[j, i+1] and
malla[j, i] < malla[j+1, i-1] and
malla[j, i] < malla[j+1, i] and
malla[j, i] < malla[j+1, i+1]):
minimos[i, j] = True
Veamos qué ocurre ahora:
In [22]:
busca_min_np_jit(data)
Out[22]:
In [23]:
np.testing.assert_array_equal(busca_min(data)[0], busca_min_np_jit(data)[0])
np.testing.assert_array_equal(busca_min(data)[1], busca_min_np_jit(data)[1])
In [24]:
%timeit busca_min_np_jit(data)
Habéis leído bien: 70x más rápido :)
¡Lo hemos conseguido! Ahora nuestro código funciona en numba sin problemas y encima es endemoniadamente rápido. Para completar la comparación en mi ordenador, voy a reproducir también la función hecha en Cython:
In [25]:
%load_ext cython
In [26]:
%%cython --name probandocython9
import numpy as np
cimport numpy as np
from cpython cimport array as c_array
from array import array
cimport cython
@cython.boundscheck(False)
@cython.wraparound(False)
cpdef tuple busca_min_cython9(double [:,:] malla):
cdef c_array.array minimosx, minimosy
cdef unsigned int i, j
cdef unsigned int ii = malla.shape[1]-1
cdef unsigned int jj = malla.shape[0]-1
cdef unsigned int start = 1
#cdef float [:, :] malla_view = malla
minimosx = array('L', [])
minimosy = array('L', [])
for i in range(start, ii):
for j in range(start, jj):
if (malla[j, i] < malla[j-1, i-1] and
malla[j, i] < malla[j-1, i] and
malla[j, i] < malla[j-1, i+1] and
malla[j, i] < malla[j, i-1] and
malla[j, i] < malla[j, i+1] and
malla[j, i] < malla[j+1, i-1] and
malla[j, i] < malla[j+1, i] and
malla[j, i] < malla[j+1, i+1]):
minimosx.append(i)
minimosy.append(j)
return np.array(minimosx), np.array(minimosy)
In [28]:
%timeit busca_min_cython9(data)
Por tanto, vemos que la versión con numba es el doble de rápida que la versión con Cython. Sobre gustos no hay nada escrito: yo por ejemplo valoro no «salirme» de Python usando numba mientras que a otro puede no importarle incluir especificaciones de tipos como en Cython. Los números, eso sí, son los números :)
El cálculo de propiedades termodinámicas de la atmósfera estándar es un problema clásico que todo aeronáutico ha afrontado alguna vez muy al principio de su carrera formativa. La teoría es simple: imponemos una ley de variación de la temperatura con la altura $T = T(h)$, la presión se obtiene por consideraciones hidrostáticas $p = p(T)$ y la densidad por la ecuación de los gases ideales $\rho = \rho(p, T)$. La particularidad de la atmósfera estándar es que imponemos que la variación de la temperatura con la altura es una función simplificada y definida a trozos, así que calcular temperatura, presión y densidad dada una altura se parece mucho a hacer esto:
In [ ]:
if 0.0 <= h < 11000.0:
T = T0 + alpha * h
p = ... # Algo que depende de T
rho = p / (R_a * T)
elif 11000.0 <= h < 20000.0:
T = T1
p = ...
rho = p / (R_a * T)
elif 20000.0 <= h <= 32000.0:
...
El problema viene cuando se quiere vectorizar esta función y permitir que h
pueda ser un array de alturas. Esto es muy conveniente cuando queremos pintar alguna propiedad con matplotlib, por ejemplo.
Se intuye que hay dos formas de hacer esto: utilizando funciones de NumPy o iterando por cada elemento del array. La primera solución se hace farragosa, y la segunda, gracias a la proverbial lentitud de Python, es extremadamente lenta. Mi amigo Álex y yo llevamos pensando sobre este problema años, y nunca hemos llegado a una solución satisfactoria (incluso encontramos algunos bugs en numpy.piecewise
por el camino). Este año decidimos cerrar este asunto definitivamente así que con el equipo AeroPython exploramos varias implementaciones distintas. Hasta que por fin lo conseguimos: usamos numba para acelerar los bucles.
Como podéis leer en la discusión original, la función de la primera columna está escrita en C++. ¿Impresionado? ;)
Para mi proyecto fin de carrera me encontré con la necesidad de calcular la deflexión de una placa rectangular, simplemente apoyada en sus cuatro bordes (es decir, los bordes pueden girar: no están empotrados) sometida a una carga transversal. Este problema tiene solución analítica conocida desde hace tiempo, hallada por Navier:
$$w(x,y) = \sum_{m=1}^\infty \sum_{n=1}^\infty \frac{a_{mn}}{\pi^4 D}\,\left(\frac{m^2}{a^2}+\frac{n^2}{b^2}\right)^{-2}\,\sin\frac{m \pi x}{a}\sin\frac{n \pi y}{b}$$siendo $a_{mn}$ los coeficientes de Fourier de la carga aplicada. Como veis, para cada punto $(x, y)$ hay que hacer una doble suma en serie; si encima queremos evaluar esto en un meshgrid
, necesitamos un cuádruple bucle. Ya se anticipa que por muy hábiles que estemos, a Python le va a costar.
La clave estuvo, una vez más, en usar numba para optimizar los bucles. En GitHub tenéis el código completo, pero la parte importante es esta:
In [5]:
@numba.jit(nopython=True)
def a_mn_point(P, a, b, xi, eta, mm, nn):
"""Navier series coefficient for concentrated load.
"""
return 4 * P * sin(mm * pi * xi / a) * sin(nn * pi * eta / b) / (a * b)
@numba.jit(nopython=True)
def plate_displacement(xx, yy, ww, a, b, P, xi, eta, D, max_m, max_n):
max_i, max_j = ww.shape
for mm in range(1, max_m):
for nn in range(1, max_n):
for ii in range(max_i):
for jj in range(max_j):
a_mn = a_mn_point(P, a, b, xi, eta, mm, nn)
ww[ii, jj] += (a_mn / (mm**2 / a**2 + nn**2 / b**2)**2
* sin(mm * pi * xx[ii, jj] / a)
* sin(nn * pi * yy[ii, jj] / b)
/ (pi**4 * D))
Podéis comprobar vosotros mismos que las diferencias de rendimiento en este caso son brutales. Y solo hemos añadido una línea a cada función.
numba aún no es una herramienta estable, pero está rápidamente alcanzando un grado de madurez suficiente para optimizar código orientado a operar con arrays. Gracias a conda es trivial de instalar y los resultados respecto a soluciones más maduras como Cython son aplastantes, tanto en velocidad de ejecución como en la complejidad del código resultante.
De momento yo me quedo con numba, ¿y tú? ;)